Skip to content

refactor(remote): OETC/SSH as standalone transports (closes #683)#697

Open
FBumann wants to merge 25 commits into
masterfrom
refactor/oetc-as-solver
Open

refactor(remote): OETC/SSH as standalone transports (closes #683)#697
FBumann wants to merge 25 commits into
masterfrom
refactor/oetc-as-solver

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented May 19, 2026

Stacked on top of #690 (rebase target after that lands on master).

Closes #683 — with a different design than the issue proposed.

Why standalone, not Solver subclass

The issue framed OETC as a Solver subclass so Model.solve could fold the remote= branch into the unified Solver pipeline. The fit was wrong:

  • Remote handlers aren't solvers — they ship a netcdf elsewhere and let someone else solve.
  • Forcing them through Solver required workarounds: a non-colliding field for the worker's solver, property-vs-field collisions on solver_name, SolverName enum entries for things that aren't algorithms.
  • Solver's feature-flag plumbing is about local solver capabilities; for remotes we want to validate the worker's solver flags.

So Oetc and SSH are standalone classes in linopy/remote/, parallel to Solver, not subclasses of it.

What's new

m.solve("gurobi", remote=OetcSettings(email=..., password=..., name=..., ...), Method=2)
m.solve("highs", remote=SshSettings(hostname=..., setup_commands=["conda activate linopy-env"]))
  • solver_name and **solver_options are the same axes as for a local solve; remote= selects where to run.
  • Model.solve(remote=...) folds the solution into your model in place and returns (status, termination_condition) — behaviour-identical to v0.7.0.
  • New model.remote attribute mirrors model.solver for post-solve introspection and connection reuse.
  • SshSettings.setup_commands: list[str] runs shell commands on the remote before the solver — replaces the old "build a RemoteHandler and call .execute(...)" pattern.
  • Pip extra remotessh; OETC has its own linopy[oetc] extra. linopy[remote] is dropped — see Breaking Changes in the release notes.

Remote calls return a Model

Oetc.solve / Oetc.collect / SSH.solve return the solved Model, not a Result. The worker (read_netcdf → m.solve → to_netcdf) natively produces a fully inflated solved model — forcing it into the flat Result shape meant a redundant Model → flat arrays → Model round-trip.

  • Model.solve(remote=...) folds the solved model into the caller's model via the new Model._assign_from_solved_model — the v0.7.0 in-place fold logic, extracted into a method. So Model.solve(remote=...) is behaviour-identical to v0.7.0: it mutates the original model and returns the status tuple.
  • The standalone Oetc / SSH objects hand back a new solved Model; the model you pass in is not modified.
  • _scatter_solution_from_solved_model is deleted. Local solves are unchanged — solvers natively emit flat label-indexed arrays, so they keep the Result / Model.assign_result path.

Each path is now direct — no flatten/reinflate hop.

Oetc is a connection, not a job

Oetc(settings) is a reusable connection: authenticate once, drive any number of jobs.

oetc   = Oetc(settings)
uuid   = oetc.submit(model, "gurobi", Method=2)   # upload + dispatch → job uuid
state  = oetc.status(uuid)                        # one non-blocking status check
solved = oetc.collect(uuid)                       # block until done → solved Model
solved = oetc.solve(model, "gurobi", Method=2)    # one-shot = submit + collect

A job is identified solely by its uuid string, which makes the lifecycle genuinely async-friendly:

  • Multi-model — one connection, a list of uuids.
  • Cross-process / resume — persist the uuid; a fresh Oetc(OetcSettings.from_env()) in another process (or hours later) collects it. collect() needs nothing but the uuid and the settings.
  • Non-blockingstatus(uuid) for a user-driven poll loop; the sync primitives compose into any async runtime via asyncio.to_thread / a thread pool.

SSH(settings) is the synchronous analogue — a reusable connection with solve(model, solver_name, **options); no submit/collect seam (the SSH session is held open and the solve is synchronous).

Solver config is per call, not per object: solver_name / **options are arguments to submit / solve / Model.solve, mirroring the local-solve API.

Token re-authentication

The OETC auth token has a limited lifetime; Oetc now re-authenticates transparently:

  • Oetc._session() rebuilds the handler when the token has expired — covers a connection reused across the token lifetime (submit, then collect later).
  • OetcHandler.wait_and_get_job_data() re-signs-in at the top of each poll iteration — so a single collect() whose blocking poll outlives the token (the normal case for large solves) keeps working. GCP transfers are unaffected; they use the service-account key, not the user JWT.

SSH is narrower than RemoteHandler

SSH covers solving a model on a remote machine — not being a general remote-shell wrapper.

Old RemoteHandler New SSH Replacement
Solve a model solve_on_remote(m) solve(m, solver_name) direct rename
Env activation before solve handler.execute("conda activate …") SshSettings.setup_commands=[…]
Reuse connection across solves implicit (long-lived handler) implicit (SSH(settings) reused) works the same
Arbitrary remote shell commands handler.execute(any cmd) drop to RemoteHandler (deprecated) or paramiko directly

Deprecations

  • OetcHandler(...) / RemoteHandler(...) construction emits DeprecationWarning. Their solve_on_oetc / solve_on_remote return contracts are unchanged; solve_on_oetc delegates to Oetc internally.
  • Model.solve(remote=<Handler>) is deprecated — pass the settings dataclass instead.
  • OetcCredentials is deprecated — pass email= / password= directly to OetcSettings.
  • OetcSettings.solver / OetcSettings.solver_options are deprecated — pass the solver to Model.solve(solver_name, ...) or Oetc.submit(model, solver_name, ...). During deprecation they are still honoured (as a fallback when Model.solve(remote=OetcSettings(...)) is called without a solver_name, and by the deprecated OetcHandler), and will be removed with OetcHandler.

Hand testing needed

The entire test/remote/ suite is mocked — no real network. Everything that touches a live OETC account or SSH server needs manual verification before merge:

OETC (account + linopy[oetc]):

  • m.solve("gurobi", remote=OetcSettings(...)) end-to-end — auth, GCP upload, job submit, poll, download, solution folded onto m.
  • Oetc(settings) driven manually — submit / status / collect; solve() one-shot.
  • Cross-process resume — submit in one process, persist the uuid, collect() it from a fresh process.
  • Token re-auth — a solve (or a submit→collect gap) longer than the OETC token lifetime; confirm it re-authenticates instead of failing. This is the one path the mocked tests cannot exercise against reality.
  • OetcSettings.from_env().
  • Deprecated paths still work: OetcHandler(...).solve_on_oetc(...), Model.solve(remote=<OetcHandler>), Model.solve(remote=OetcSettings(..., solver="gurobi")).

SSH (remote server + linopy[ssh]):

  • m.solve("gurobi", remote=SshSettings(hostname=..., setup_commands=[...])) end-to-end.
  • SSH(settings) reused across several .solve() calls — one connection, setup_commands run once.
  • Deprecated paths: RemoteHandler(...).solve_on_remote(...), Model.solve(remote=<RemoteHandler>).

Docs:

  • examples/remote-machines.ipynb against real services (it is nbsphinx execute=never, so never CI-checked).

Follow-ups (not in this PR)

  • Remove OetcHandler / RemoteHandler. Their private transport methods still live on the Handler classes; Oetc / SSH reach into self._handler._... to use them. A follow-up will migrate that code into Oetc / SSH and port the OETC handler-internals tests.
  • Remove OetcCredentials, OetcSettings.solver / solver_options — after one release cycle of deprecation warnings.
  • In-poll re-auth is proactive (is_expired check); a reactive re-auth on a 401 would also cover a server expiring the token earlier than advertised.

Test plan

  • pytest test/remote/ test/test_sos_reformulation.py test/test_oetc_settings.py — 193 pass (all mocked).
  • ruff check, ruff format, mypy — clean.
  • Broader suite (pytest --ignore=test/remote) — re-run before merge.
  • Hand testing — see the checklist above.

🤖 Generated with Claude Code

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FBumann I think the concept makes sense. I would like to make sure that it is flexible enough for interfaces like gurobi instant cloud. could you take that into consideration?

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FabianHofmann Yes, I already did think about it. But i can sketch it out a bit more, maybe even create a Remote parent class...
I also didnt add the tests yet, and some cleanupy might also be possible. And the latest context manager from #690 isnt used yet

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FBumann I think the concept makes sense. I would like to make sure that it is flexible enough for interfaces like gurobi instant cloud. could you take that into consideration?

It fits well for async instant cloud. For the sync instan cloud it doesnt fit as cleanly. But I think it can be wired in without adjusting public api. Probably just refactor some internal helpers for collection

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FBumann I think the concept makes sense. I would like to make sure that it is flexible enough for interfaces like gurobi instant cloud. could you take that into consideration?

It fits well for async instant cloud. For the sync instan cloud it doesnt fit as cleanly. But I think it can be wired in without adjusting public api. Probably just refactor some internal helpers for collection

I guess async is the most important one

Base automatically changed from refactor/sos-reformulation-methods to master May 19, 2026 10:00
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FBumann I think the concept makes sense. I would like to make sure that it is flexible enough for interfaces like gurobi instant cloud. could you take that into consideration?

It fits well for async instant cloud. For the sync instan cloud it doesnt fit as cleanly. But I think it can be wired in without adjusting public api. Probably just refactor some internal helpers for collection

I guess async is the most important one

I thin the regular gurobi cloud can be used by just using the regular gurobi solver anyway...? It fully dispatches to gurobipy, and gets a gurobipy solution back, right? SO no extra class needed...

@FBumann FBumann force-pushed the refactor/oetc-as-solver branch from b221cc3 to 80e8be1 Compare May 19, 2026 10:21
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FabianHofmann
Here is some advice from CLaude after some back and forth:

## Generality check — Gurobi & other clouds

- Gurobi Instant Cloud / Compute Server sync → already works via m.solve("gurobi", env=cloud_env). Belongs as GurobiSolver config, not a remote class. A GurobiInstantCloud remote class would be a fake — construct an Env and dispatch back into the regular Solver path.
- Gurobi batch solve / detached submit-now-collect-later → genuine fit for the remote handler shape.
- Friction for any future handler: solver_name is mandatory and means inner solver (redundant for solver-specific clouds); _scatter_solution_from_solved_model assumes a netcdf round-trip with a linopy Model on the other side.

## Naming clarity

"Remote" is defensible as user-facing vocabulary but mislabels the technical category, and "async" is the wrong axis entirely:

- The actual invariant shared by Oetc and SSH is a second linopy instance does the solve (local linopy serializes → remote linopy deserializes → .solve() → result back). Gurobi Cloud is also remote geographically, but only one linopy instance is involved — gurobipy talks to a remote engine too.
- "Async" misclassifies SSH (fully synchronous, no job seam) and overpromises on Oetc (.solve() blocks; the seam is optional). True async (blocking=False returning a handle) is orthogonal — it could apply to a local callback-driven Gurobi solve too.

SO the new architecture should cover all cases. THe only issue i see is thst "remote" isnt a precise name. "worker" or "offload" might be better. But thats not as important for now i think

Closes #683.

The issue framed OETC as a `Solver` subclass to fold the `remote=`
branch in `Model.solve` into the unified Solver pipeline. Trying that,
the fit was wrong: remote handlers aren't solvers — they ship a netcdf
elsewhere and let someone else solve. Forcing them through `Solver`
required workarounds (a non-colliding `inner_solver` field name,
property-vs-field collisions on `solver_name`, `SolverName` enum entries
for things that aren't algorithms).

Going standalone instead:

- `linopy.remote.Oetc(settings, solver_name, options)` — standalone
  class with `upload(model)` / `submit()` / `collect(model)` /
  `solve(model)` lifecycle. The submit/collect split is in the right
  shape for future async work (a `blocking=False` solve, Gurobi-batch,
  etc.) without baking the seam into the Solver hierarchy.
- `linopy.remote.SSH(settings, solver_name, options)` — synchronous
  ship-and-run handler.
- Both produce a label-indexed `Result` via the shared
  `_scatter_solution_from_solved_model` helper in
  `linopy/remote/_common.py`.
- Both validate the inner solver locally via `_validate_inner_solver`
  (unknown name raises; known-but-incapable raises before the
  round-trip).

Settings dataclasses now pure transport. `OetcSettings.solver` and
`OetcSettings.solver_options` are removed — those config axes live on
the outer `Model.solve` call now, mirroring the local-solve API. New
`SshSettings` follows the same shape.

`Model.solve` changes:

- `remote=<Settings>` → standalone-handler dispatch via the new
  `_solve_with_remote_settings` method.
- `remote=OetcHandler/RemoteHandler` → legacy shim, emits
  `DeprecationWarning`, builds equivalent settings, routes to the same
  new pipeline.
- New `model.remote` slot — set to the `Oetc`/`SSH` instance after a
  remote solve, lets callers introspect `model.remote._job_uuid` etc.
  `model.solver` is None during remote solves.

The reformulation lifecycle (from #690) wraps the remote dispatch via
`sos_reformulation_context` + `suppress_serialization_warning`, the
same context managers the local-solve path uses. The `to_netcdf`
UserWarning is suppressed for the handler's internal serialization.

`OetcHandler.solve_on_oetc` emits a `DeprecationWarning` when called
directly, pointing at the new API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann FBumann force-pushed the refactor/oetc-as-solver branch from 80e8be1 to daadcae Compare May 19, 2026 12:28
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FabianHofmann I argue that the final form of API would be removing OetcHandler.
The High level api would be OetcSettings passed to Model.solve(). The low level API is using Oetc.from_model() like a Solver.

OetcHandler delegates for backwards compat, but is deprecated.

This reflects the 2-level state of regular solvers: Pass name + options in Model.solve(), or directly invoke with the Solver class, which holds the logic/validation for the solver.

Do you agree?

FBumann and others added 16 commits May 19, 2026 16:06
…eprecate legacy handlers

- `OetcHandler.__init__` / `RemoteHandler.__post_init__` emit
  `DeprecationWarning` pointing at `Oetc` / `SSH` and
  `Model.solve(remote=...)`. An `_internal=True` kwarg suppresses the
  warning when the new classes construct the handler themselves.
- `OetcHandler.solve_on_oetc` delegates to `Oetc.solve` so the
  upload→submit→poll→download orchestration lives in one place.
  Legacy `Model` return shape preserved by reading `oetc._solved_model`
  after `collect`.
- `Oetc.upload` / `SSH.solve` no-op handler construction when one is
  already attached, so the deprecated handler can be reused as the
  underlying transport without re-running auth.
- Validation moved into `Oetc.solve` (was in `upload`) so the legacy
  handler path is unchanged for users.

Two `TestSolveOnOetc` tests grow a few mock attrs (`_xCounter=0`,
empty `.items()`, `termination_condition`) so the bare `Mock()` model
flows through `Oetc.collect`'s scatter step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the new `Oetc` / `SSH` standalone classes, the
`Model.solve(remote=<Settings>)` entry point, and the deprecation of
`OetcHandler` / `RemoteHandler`. Migration examples show both the
recommended `Model.solve(remote=...)` path and the direct
`Oetc.solve(m)` + `assign_result` path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`SshSettings.setup_commands` is a list of shell commands run on the
remote interactive session before the inner solver is invoked —
e.g. `setup_commands=["conda activate linopy-env"]`. Replaces the
old pattern of holding a `RemoteHandler` instance and manually
calling `.execute(...)`.

The `examples/solve-on-remote.ipynb` notebook is rewritten to:
- use `Model.solve(remote=SshSettings(...))` as the primary path,
- demonstrate `setup_commands` for env activation,
- show `SSH(settings, solver_name, options).solve(m)` as the
  advanced "drive the transport directly" path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drops `OetcHandler` cells (deprecated) — primary path is now
  `Model.solve("gurobi", remote=OetcSettings(...), **opts)`.
- Removes the settings-level `solver=` / `solver_options=` cell;
  inner solver name and options live at the call site, matching the
  local-solve shape.
- Replaces the retry/error-handling cell with an "Advanced" section
  that walks through `Oetc.upload` / `Oetc.submit` / `Oetc.collect`
  — the async-friendly seam that motivates the standalone class.
- Trims to essentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rewritten notebooks dropped the notebook-level
`"nbsphinx": {"execute": "never"}` metadata, which both prior
versions had. Without it, the docs build tries to execute the cells
and fails on `os.environ["OETC_EMAIL"]` / a live SSH connect.

Restore the original metadata so the docs build returns to rendering
the notebooks as static content.
OetcCredentials was a 2-field wrapper (email, password) that
added an extra construction layer with no functional payoff. Inline
the two fields onto OetcSettings so the construction shape matches
SshSettings (which takes username/password directly).

OetcCredentials stays importable and emits a DeprecationWarning
on construction; OetcSettings(credentials=...) is still accepted
and copies the values through. To be removed in a future release.

Note: the positional argument order on OetcSettings shifts because
credentials is no longer the first required field. Existing
keyword-arg callers (the typical case) are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… guide

The two notebooks duplicated their model-creation cells and "Advanced:
drive the transport directly" sections, while users picking a remote
transport read one or the other — not both. Merge into a single
`remote-machines.ipynb` with parallel SSH / OETC sections and a
shared advanced section, plus a brief "which to pick?" table.

Rename keeps the file out of the "solve-on-*" namespace (the
docs section is already "Solving"); `remote-machines` describes
what the page is about, not what you do with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Switch the manual OetcSettings example from os.environ[...] to literal
  placeholder strings. Mixing os.environ access with the manual-construction
  example was confusing — environment loading is what from_env() is for.
- Drop the SSH-vs-OETC comparison table at the end. The information is
  obvious from each section's 'What you need' bullets.
The `remote` extra installed only `paramiko` — i.e., the SSH transport
deps. With OETC as a parallel transport (own `linopy[oetc]` extra),
the `remote` name was misleading and asymmetric. Rename to `ssh` to
match what it installs.

Drop the old `remote` extra (rather than alias it) because:
- It only shipped in v0.7.0 (recent, narrow adoption).
- Pip extras have no runtime deprecation mechanism, so the alias
  would just defer an inevitable break.
- Aliasing leaves a redundant extra in the API surface.

Documented under "Breaking Changes" in the release notes; the merged
remote-machines notebook is updated to use `linopy[ssh]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `test/remote/test_remotes.py` covering the new public surface
that `test_oetc.py` and `test_ssh.py` don't (those still focus
on the deprecated Handler classes):

- `Oetc.solve` happy path with a mocked `OetcHandler`.
- `Oetc.upload` / `submit` / `collect` as separable steps.
- `SSH.solve` happy path; `SshSettings.setup_commands` runs on the
  remote shell on first handler construction.
- Inner-solver validation (unknown name raises in both transports).
- `Model.solve(remote=OetcSettings(...))` / `Model.solve(remote=SshSettings(...))`
  end-to-end with `Oetc.solve` / `SSH.solve` monkeypatched.
- Deprecation warnings on `OetcHandler`, `RemoteHandler`,
  `OetcCredentials`, and `Model.solve(remote=<Handler>)`.
- `_internal=True` suppresses the handler deprecation warnings on
  the construction path used internally by `Oetc` / `SSH`.

Also updates `test-notebooks` skip-list for the renamed merged
notebook (`remote-machines.ipynb` replaces `solve-on-{remote,oetc}.ipynb`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The API page only documented the deprecated `RemoteHandler`. Add the
new public classes (`SSH`, `Oetc`, `SshSettings`, `OetcSettings`) and
the remaining deprecated entries (`OetcHandler`, `OetcCredentials`)
so autosummary generates a stub for each. The new entries link to
the merged `remote-machines` user guide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann FBumann marked this pull request as ready for review May 19, 2026 18:17
@FBumann FBumann requested a review from FabianHofmann May 19, 2026 18:18
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 19, 2026

@FabianHofmann Ready for a review

FBumann and others added 2 commits May 20, 2026 15:32
Resolved conflict in examples/solve-on-oetc.ipynb: the PR intentionally
deletes this notebook (replaced by examples/remote-machines.ipynb), while
master only applied cosmetic nbstripout/unicode normalization. Kept the
deletion.
…ng text

A user passing m.solve("gurobi", remote=...) only ever supplies one
solver, and remotes are transports rather than solvers, so "inner" has
no "outer" to contrast with. Drop it from docstrings, validation error
messages, and release notes. Internal symbols (inner_solver param,
_validate_inner_solver) keep the name — there it disambiguates the
shipped solver string from the transport object.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 20, 2026

@FabianHofmann ready. Do you wait for feedback of oetc users?

FBumann and others added 2 commits May 20, 2026 17:02
The :class: target pointed at linopy.solvers.SSH, but SSH is exported
from linopy.remote and is a transport, not a solver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The public API is already "remote" (remote= kwarg, model.remote
attribute, linopy.remote module), so prose naming the same concept
"transport" gave readers two words for one thing. "Transport-only" as
a qualifier stays — it has no plain "remote" equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 21, 2026

@FabianHofmann Ill look into the async stuff for oetc a bit, but cant test without oetc credentials. SO we will see how far i can go

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 21, 2026

As im looking into async usage with oetc, the design decision of having Ssh and Oetc return Result like regular solvers bites me. With this design, Remotes manipulate the original model, which therefore needs to keep existing, instead of simply returning the collected one.

So i think changing this to make Ssh/Oetc return the the collected Model is better in many ways, even if its conceptually different from what Solvers do!

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 21, 2026

Design change since the description: Oetc/SSH return a solved Model, not a Result.

The "Remote returns Model instead of Result" alternative — listed as rejected under "Other designs considered" — is now the chosen design.

Key rationale: Oetc / SSH get a fully solved model back from the worker, so the simplest thing is to return it as-is. Model.solve(remote=...) is the only path that needs to write the solution into the caller's existing model.

  • Oetc.solve() / Oetc.collect() / SSH.solve() return the solved Model directly — no conversion. collect() no longer takes the source model, so it is process-independent (collect a job by uuid from a fresh process).
  • Model.solve(remote=...) folds the solved model into the caller's model via the new Model._assign_from_solved_model — the v0.7.0 in-place fold logic (model.py lines 1531-1540) extracted into a method. So Model.solve(remote=...) is behaviour-identical to v0.7.0: it mutates the original model and returns (status, termination_condition), never a Model.
  • _scatter_solution_from_solved_model (the Model → flat Result adapter) is deleted. Local solves keep Result / assign_result — solvers natively emit flat label-indexed arrays.

To be folded into the PR description.

FBumann and others added 4 commits May 21, 2026 14:34
Two changes to the standalone remote API:

Return Model, not Result. `Oetc.solve` / `Oetc.collect` / `SSH.solve`
return the solved `Model` directly. The worker already produces an
inflated solved model, so the `Result` shape forced a redundant
Model -> flat -> Model round-trip. `Model.solve(remote=...)` now folds
the solved model into the caller's model via the new
`Model._assign_from_solved_model` — the same in-place fold v0.7.0 used,
so `Model.solve(remote=...)` behaviour is unchanged. The
`_scatter_solution_from_solved_model` helper is removed; local solves
keep the `Result` / `assign_result` path.

Oetc is a connection, not a job. `Oetc(settings)` authenticates once
and drives any number of jobs: `submit(model, solver_name, **options)`
returns a job uuid, `collect(uuid)` returns the solved model,
`status(uuid)` is a non-blocking status check, `solve(...)` is the
one-shot. Because a uuid is the only job handle, multi-model,
cross-process and non-blocking orchestration all work without linopy
owning an event loop. `SSH` takes the matching `SSH(settings)` /
`solve(model, solver_name, **options)` shape.

`OetcSettings.solver` / `solver_options` are deprecated; pass the
solver to `Model.solve` / `Oetc.submit` instead. They are still
honoured during deprecation (as a `Model.solve` fallback and by the
deprecated `OetcHandler`) and will be removed with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename "transport config" to "connection config", describe Oetc as a
token-based session rather than a connection, frame SSH vs OETC as
self-hosted vs managed, and fix stale API signatures in the OetcHandler
and RemoteHandler deprecation messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OETC auth token has a limited lifetime, but nothing ever refreshed
it. Two cases failed:

- A reused `Oetc` connection — submit, then collect after the token
  lifetime — sent stale-token requests that 401'd.
- A single `collect()` whose blocking poll outlived the token, which is
  the normal case for the large, long-running solves OETC exists for.

`Oetc._session()` now rebuilds the handler when `jwt.is_expired`, and
`OetcHandler.wait_and_get_job_data()` re-signs-in at the top of each
poll iteration. GCP transfers are unaffected — they use the
service-account key, not the user JWT.

Test mocks now set `jwt.is_expired = False` (a freshly built handler
has a live token) so the new checks do not misfire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 21, 2026

@FabianHofmann I put some more work in. Its now truly ready for async polling, fully relying on a job uuid.
But i cant test it, see the notes on the PR description

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor OETC as a Solver subclass

2 participants